feat(vllm-cpp): enable and vendor the MLX GEMM provider on darwin/metal - #11137
Conversation
f1c213b to
927206e
Compare
|
@localai-org-maint-bot pick this up and verify against vllm.cpp and current state. rebase, fix CI |
64cddf8 to
9fdb13d
Compare
|
Rebased onto current |
|
@localai-org-maint-bot rebase and fix CI issues |
9fdb13d to
b6ad37d
Compare
|
Rebased onto current |
|
@localai-org-maint-bot fix ci |
b6ad37d to
d480f9f
Compare
|
Rebased onto current |
|
Resumed after the fresh Darwin run exposed that the first suppression was ineffective. Pushed |
ffe14a7 to
19ad1db
Compare
|
Resumed the unfinished Darwin failure, rebased onto current |
|
Resumed the remaining Darwin failure and moved the fix to the component that owns the warning policy. Opened mudler/vllm.cpp#23 with an AppleClang-only, source-scoped exception for |
|
Resumed the new Darwin failure. The source-level |
|
Resumed the completed Darwin failure and fixed the warning at its owning layer. The prior CMake source option was ordered before vllm.cpp’s target-level Objective-C++ |
|
@localai-org-maint-bot that was merged |
ca26d72 to
5f9f886
Compare
|
Resumed after the upstream merge and pushed |
5f9f886 to
5f62b9a
Compare
|
Resumed the remaining Darwin failure and fixed the warning at the dependency boundary. #11174 was not a valid comparison because it pins the same vllm.cpp revision but does not enable MLX; #11137 uniquely compiles MLX headers under the project warning policy. Opened mudler/vllm.cpp#27 to model MLX as an imported SYSTEM dependency and remove the ineffective translation-unit pragma, then rebased this PR onto current |
5f62b9a to
d9182f9
Compare
|
Resumed after the completed Darwin run showed that the imported Fresh LocalAI verification passed: companion commit reachability, exact pin assertion, packaging shell syntax, Metal+MLX Make dry-run, and |
The darwin vllm-cpp image built the Metal backend with vllm.cpp's native MSL
GEMM only. vllm.cpp also ships an optional MLX provider for the dense GEMM,
kept OFF upstream because it costs a ~19 MB libmlx.dylib plus a ~105 MB
mlx.metallib, on the stated position that it must earn that cost by
measurement.
Measured on an Apple M4 (16 GiB, macOS 26.5.2) it does. One binary, arms
toggled with VT_OP_PROVIDER_DISABLE=mlx so there is no build-difference
confound, Qwen3-1.7B-bf16 p=512 g=128, 2 reps, arm order alternated per rep:
B=1 5.79 vs 3.08 agg tok/s (1.88x) TTFT 3.32 s vs 7.68 s
B=8 25.70 vs 13.69 (1.88x) TTFT 13.95 s vs 34.38 s
B=16 38.65 vs 17.69 (2.19x) TTFT 18.33 s vs 54.48 s
Peak RSS is unchanged (6.65 to 7.50 GB in both arms) and the output is
bit-identical: vllm.cpp's three-way parity test measures mlx-vs-msl NMSE of 0
on all six shapes, and mlx-vs-cpu equal to msl-vs-cpu, against a 5e-4 bar. MLX
serves the dense GEMM alone; paged attention stays vllm.cpp's own kernel
because MLX has no paged-KV primitive. Full disposition, including the
INDICATIVE status and the isolation actually achieved, is in vllm.cpp
docs/BENCHMARKS.md "MLX GEMM provider A/B on Apple M4".
Build: MLX comes from the pinned prebuilt pip wheel (MLX_VERSION, default
0.29.3) into a venv under the backend dir. Building MLX from source needs
`xcrun metal`, i.e. a full Xcode the macOS runners do not have, while the wheel
ships include/, lib/libmlx.dylib and the compiled metallib ready to link. The
install is a stamp FILE rather than a phony target, because a phony
prerequisite is always newer than libvllm and would re-link it every
invocation. VLLM_CPP_MLX=off restores the previous Metal build.
Packaging vendors libmlx.dylib, mlx.metallib and MLX's MIT license into
package/lib/. Three things this had to get right, each verified on the M4
before it was written rather than after:
1. libvllm.dylib links @rpath/libmlx.dylib and its build-time LC_RPATH points
inside the build venv, a path no user has. Every build rpath is deleted
and replaced with @loader_path/lib.
2. MLX loads its metallib from beside its OWN dylib, so both files must land
in the same directory or every Metal op fails with "Failed to load the
default metallib".
3. install_name_tool invalidates the code signature and macOS refuses to load
an arm64 image with a stale one, so the patched library is re-signed
ad-hoc.
Verified end to end on the M4 by building through this Makefile and running the
packaged artifact: `DYLD_PRINT_LIBRARIES` resolves libmlx from package/lib/,
`codesign -v` passes, no build-venv path survives in the load commands, and a
real generation runs with the provider selected (op=65 selected=mlx) and zero
metallib failures. A missing rpath now fails the build instead of the user's
first inference.
Cost: the darwin vllm-cpp image grows by about 124 MB.
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [ClaudeCode]
This branch opened with VLLM_CPP_MLX=on, justified by an A/B that measured the MLX provider at 1.88x to 2.19x against the native MSL GEMM. That measurement was correct when taken and is now stale: vllm.cpp's own Metal kernels have improved several-fold since, through mma prefill attention, a vectorised decode V accumulation, vectorised attention staging, a fused qk-norm-RoPE preamble and a simdgroup-per-row softmax. The native path MLX was compared against no longer exists. Re-measured on the same Apple M4, in the same binary, with the arms toggled by VT_OP_PROVIDER_DISABLE=mlx, on Qwen3-1.7B-bf16 warm at p=512 g=128: MLX provider ON prefill TTFT 1370 ms warm throughput 11.98 tok/s MLX provider OFF prefill TTFT 1400 ms warm throughput 22.06 tok/s Shipping the previous default would have halved Apple Silicon throughput. MLX's steel GEMM is still about 20% faster than ours in isolation, but the provider pays a per-op mx::eval synchronisation plus an output memcpy, because it cannot write into our buffer. Across prefill's roughly 112 GEMMs that overhead leaves a 2% gain; on decode, where the same synchronisation is paid once per matmul per token, it costs 46%. The option is kept for prefill-dominated workloads, where the margin is small but real. The README section is rewritten rather than patched: it previously presented the stale table as the reason for the default, so leaving it in place would have made the new default look arbitrary. Assisted-by: Claude Code:claude-opus-5 [ClaudeCode] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Bumps VLLM_CPP_VERSION from 9e1c9025 to eec09bed and turns VLLM_CPP_MLX back on. These two must move together, which is why they are one commit. Upstream now shape-gates the MLX provider to prefill: it declines m < 2, which is exactly the decode GEMV. MLX's steel GEMM wins prefill, 524.5 ms of TTFT against 602 for the native path, but loses decode badly because the provider pays an mx::eval synchronisation and an output memcpy on every call while decode makes about 112 calls per token. Ungated it does both; gated it does only the good half. Measured on an Apple M4 with Qwen3-1.7B-bf16 warm at p=512 g=128: MLX gated to prefill (pin >= 89c46aeb) TTFT 524.5 ms 24.40 tok/s, 99.1% of MLX-LM MLX ungated (older pins) TTFT 537 ms 12.7 tok/s MLX off TTFT 602 ms 23.9 tok/s This branch briefly defaulted the provider off, which was the correct call for an ungated provider at the old pin. The gate is what makes on correct again, so the pin and the flag are coupled: rolling VLLM_CPP_VERSION back before 89c46aeb while leaving MLX on would select the middle row and roughly halve throughput. Both the Makefile comment and the README state that dependency explicitly. The bump also brings six Metal kernels landed upstream since the old pin — mma prefill attention, a vectorised decode V accumulation, vectorised attention staging, a fused qk-norm-RoPE preamble, a simdgroup-per-row softmax and a simdgroup-per-head preamble — which take the non-MLX Metal path from 89.4% to 96.4% of MLX-LM on their own. One caveat, recorded in the README: MLX's GEMM is not bit-identical to the native kernel, so an MLX build produces a different greedy sequence than a non-MLX build. That is a property of the provider rather than of the gate and predates this packaging. Assisted-by: Claude Code:claude-opus-5 [ClaudeCode] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The previous commit quoted 99.1% of MLX-LM for the prefill-gated MLX build. That figure divided by a two-run MLX-LM baseline, 27.135 and 27.744 generation tok/s averaged to 27.44. Re-measured interleaved with ours over four ABBA blocks, MLX-LM's decode is 27.848 with a 0.34% spread across six runs, so the 27.135 was an outlier and averaging it in overstated us by roughly 1.5 points. Corrected: the gated configuration is 24.37 tok/s, or 97.6% of MLX-LM, and the MLX-off build is 23.9 tok/s or 95.9%. Prefill TTFT is unchanged at 524.5 ms against MLX-LM's 532.6, so we remain about 1.5% faster there. Nothing else changes. MLX still wins prefill and loses decode, the shape gate is still the right disposition, and the pin and the flag are still coupled. The gate is worth about 1.7 points over the MLX-off build rather than 2.7. Assisted-by: Claude Code:claude-opus-5 [ClaudeCode] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The previous pin was a merge commit from the experimental C ABI v9 branch. Pin the same MLX prefill gate on upstream main so the backend build does not pull unrelated ABI v9 work into every platform variant. Assisted-by: Codex:gpt-5 [systematic-debugging]
Keep the current master pin when enabling MLX so every backend variant builds against the known-good vllm.cpp revision. Suppress Apple clang’s GNU constant-folding diagnostic for Objective-C++ Metal compilation only, since upstream treats warnings as errors. Assisted-by: Codex:gpt-5 [systematic-debugging]
MLX 0.29.3 headers trigger Apple clang's gnu-folding-constant diagnostic in the Objective-C++ provider. Keep the diagnostic visible while exempting only it from vllm.cpp's global warnings-as-errors policy. Assisted-by: Codex:gpt-5 [systematic-debugging]
Target-level Objective-C++ -Werror is appended after the directory flags, so a no-error demotion is re-promoted. Disable this single warning for the MLX header while keeping every other warning fatal. Assisted-by: Codex:gpt-5 [systematic-debugging]
Move the AppleClang warning exception into vllm.cpp where its target warning policy is defined, and pin LocalAI to that source-scoped fix. Assisted-by: Codex:gpt-5
The source-scoped no-error flag was overridden by the target warning policy. Pin the companion vllm.cpp change that disables only the MLX header diagnostic for its Objective-C++ translation unit. Assisted-by: Codex:gpt-5
Pin the companion vllm.cpp correction that scopes the AppleClang folding warning suppression inside the MLX translation unit, after command-line warning policy. Assisted-by: Codex:gpt-5 [systematic-debugging]
Advance the MLX-enabled backend to the vllm.cpp revision already validated by the dependency update branch. This includes the feature guards and AppleClang pragma boundary needed by the Darwin build. Assisted-by: Codex:gpt-5 [systematic-debugging]
Pin the companion vllm.cpp change that models MLX as an imported system dependency, keeping third-party header diagnostics out of the project's warnings-as-errors policy while retaining fatal warnings for project sources. Assisted-by: Codex:gpt-5 [Codex]
Advance vllm.cpp to the companion fix that keeps MLX headers on a SYSTEM dependency and scopes AppleClang folding-constant suppression to the external includes. Assisted-by: Codex:gpt-5 [systematic-debugging] [test-driven-development]
MLX 0.29.3 is no longer available to the Darwin runner, so the backend build stopped before CMake. Pin the first available compatible wheel and keep the documented default in sync. Assisted-by: Codex:gpt-5
31b2b0a to
be44cd4
Compare
|
@mudler resumed the completed Darwin failure and pushed Verification passed: official PyPI metadata confirms MLX 0.29.4 provides macOS arm64 wheels for CPython 3.10-3.14; the forced Darwin/Metal Make dry-run installs |
) * docs(blog): final figures for the 4.8 post, and the MLX provider The cycle closed at 374 PRs over twenty-one days, not the 321 over eighteen the post was written against. Corrects the summary, the opening line, the contributor count and the gallery total, and moves the date to the day the release is cut. Adds the MLX GEMM provider (#11137), which merged after the post was written and is the one number an Apple Silicon reader wants: 1.54x to 2.19x on an M4 with time to first token roughly halving, both arms toggled on one binary. The +/-10% caveat travels with the table rather than being left in the PR. Two lines edited against the no-ai-slop skill while I was in the file, the same pass #11324 ran over the engines post: - The opener balanced two clauses across a colon and closed on "without lying to you", which is the built-to-be-quoted shape readers picked out of the HN thread. It is a flat statement now. - "This is a new modality rather than a new backend under an existing one" is a binary contrast that says nothing the next clause does not. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] * docs(blog): call vllm.cpp alpha, and finish the no-ai-slop pass vllm.cpp is not a released backend and the post read like it was. The old wording buried the caveat in a block quote at the end of the section and still said "first release of a young engine". It now says plainly, before the caveat can be skipped, that these are alpha development builds, that shipping them in 4.8 is about letting people try the thing rather than recommending it, and that llama-cpp stays the default. Also completes the no-ai-slop pass I had only half run. Counting the lines built to be quoted, headings and section endings included, the post is in reasonable shape: long flat informational stretches, tables followed by a plain finding, headings that are labels rather than epigram-verdicts. Three patterns survived, each one an item in eval.md: - "and inverts that:" set the usual shape against ours across a colon. The sentence works without the frame. - "Two things were conflated there: a signal, which needs one line, and the detail, which needs somewhere to put it" is a role-assignment pair. Says what happens instead. - "The maturity statement from the release notes is worth repeating in full" is throat-clearing in front of a quote, and the quote is gone. Left the rest alone. Minimum effective edit, not a rewrite. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] * docs(blog): present vllm.cpp as a community project, with its own numbers The post described vllm.cpp as "a from-scratch port of vLLM, written and maintained by the LocalAI team". Two things wrong with that. It is a community project, and it has stopped being only a port: it loads GGUF, runs on CPU, Metal and Vulkan, ships speculative decoding and KV offload, and its benchmark page measures against llama.cpp, MLX-LM and DwarfStar as well as vLLM, because those are the engines it competes with on that hardware. vLLM's role is now stated for what it is, the reference implementation. Correctness is checked against it and the scoreboard is kept against it. Also flags that the name will probably change, since it is drifting far enough that vllm.cpp will eventually mislead. Adds real numbers from the project's own docs/BENCHMARKS.md rather than adjectives: 1.045x vLLM at concurrency 1 on Qwen3.6-27B NVFP4 with token-for-token identical output, 1.010x and 1.013x at c16 and c32 on the 35B MoE and behind below that, prefill 1.18x over llama.cpp on CPU aarch64, 97.6% of MLX-LM warm total on an M4. Upstream's own caution travels with them: it treats c2 through c32 as ties because its noise band is 0.5% and those margins are 0.7% to 1.7%. Every figure was checked against ~/_git/vllm.cpp/docs/BENCHMARKS.md rather than restated from memory. The heading is marked alpha to match the section body. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] * docs(blog): say who maintains vllm.cpp, and add the DeepSeek Flash result Two corrections to the previous commit. "A community project" says nothing and was not quite true either. The LocalAI team maintains vllm.cpp. Community-first is the intent, not a description, so it now says that and says what backs it: its own repository, its own docs, benchmark record and issue tracker, and it runs without LocalAI anywhere in the picture. Adds the DeepSeek-V4-Flash result, which makes the divergence point better than any of the prose around it. That model does not run on vLLM on a single GB10: every vLLM-loadable checkpoint is 156 GB or more against a 119 GiB unified pool, and the only quant that fits is an extreme-low-bit GGUF that vLLM cannot load. vllm.cpp reads GGUF and runs it at 16.28 tok/s against ds4's 16.33, a parity result. Also notes MTP speculative decoding, token-identical to vLLM's and about 4% faster at concurrency 1. Both figures checked against ~/_git/vllm.cpp/docs/BENCHMARKS.md. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] * docs(blog): lead the DeepSeek result with what we run, not with what vLLM cannot The previous version opened on "that model does not run on vLLM on a single GB10 at all". Wrong emphasis twice over: it makes a strong negative claim about another project the headline, and it buries the actual result, which is that vllm.cpp runs DeepSeek-V4-Flash at roughly 2-bit (IQ2_XXS mixed, about 80 GB) on a single DGX Spark and decodes at 16.28 tok/s against DwarfStar's 16.33. The size constraint is still there, stated as the reason the quant is what it is rather than as a point about vLLM: at 300B+ total parameters even a 4-bit checkpoint is 156 GB or more, so a 2-bit GGUF is what fits the Spark's 119 GiB unified pool. The table row now names the quant and the box (IQ2_XXS, one DGX Spark) instead of just "GGUF, GB10", since that is the part a reader with a Spark wants. Figures unchanged and still from ~/_git/vllm.cpp/docs/BENCHMARKS.md. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] * docs(blog): say the new name is undecided "The name will probably change at some point" invited the obvious question. It now says the rename is expected and the name is still to be decided, which is the actual state. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
What
Builds the darwin
vllm-cppimage with vllm.cpp's optional MLX GEMM provider and vendors its runtime into the backend package.vllm.cpp keeps
VLLM_CPP_MLXOFF by default because it costs a ~19 MBlibmlx.dylibplus a ~105 MBmlx.metallib, on the explicit position that it must earn that cost by measurement.Why: it earns it
Measured on an Apple M4 (16 GiB, macOS 26.5.2). One binary, arms toggled with
VT_OP_PROVIDER_DISABLE=mlxso there is no build-difference confound. Qwen3-1.7B-bf16, p=512 g=128, 2 reps, arm order alternated per rep to cancel thermal drift.Peak RSS is unchanged (6.65 to 7.50 GB in both arms), and the output is bit-identical: vllm.cpp's three-way parity test reports
mlx-vs-mslNMSE of0on all six shapes andmlx-vs-cpuequal tomsl-vs-cpu, against a 5e-4 bar.MLX serves the dense GEMM only. Paged attention stays vllm.cpp's own kernel, because MLX has no paged-KV primitive at all.
Honest limits: the numbers are INDICATIVE, not binding. The three
actions.runneragents on the box were verified idle, booted out for the sweep and restored, and the whole A/B ran inside onelockfso nothing could interleave between arms, butcom.localai.workerand the aerial wallpaper stayed up (no passwordless sudo). Rep spread reached 9.4% on 2 reps, so read the multipliers as +/-10%; the gap far exceeds the noise. Full disposition in vllm.cppdocs/BENCHMARKS.md, "MLX GEMM provider A/B on Apple M4" (mudler/vllm.cpp@41d7f8d7).How
Build. MLX comes from the pinned prebuilt pip wheel (
MLX_VERSION, default0.29.3) into a venv under the backend dir. Building MLX from source needsxcrun metal, a full Xcode the macOS runners do not have; the wheel shipsinclude/,lib/libmlx.dyliband the compiled metallib ready to link. The install is a stamp file rather than a phony target, since a phony prerequisite is always newer thanlibvllmand would re-link it on every invocation.VLLM_CPP_MLX=offrestores the previous Metal build.Packaging. Vendors
libmlx.dylib,mlx.metalliband MLX's MIT license intopackage/lib/. Three things this had to get right, each verified on the M4 before it was written:libvllm.dyliblinks@rpath/libmlx.dyliband its build-timeLC_RPATHpoints inside the build venv, a path no user has. Every build rpath is deleted and replaced with@loader_path/lib.install_name_toolinvalidates the code signature and macOS refuses to load an arm64 image with a stale one, so the patched library is re-signed ad-hoc.Verification
Built through this Makefile on the M4 and ran the packaged artifact:
DYLD_PRINT_LIBRARIESresolves libmlx frompackage/lib/libmlx.dylib, not the build venvcodesign -vpasses on the patched libraryop=65 selected=mlx priority=100) and zero metallib failuresA missing rpath now fails the build rather than the user's first inference request.
Also caught and fixed during verification: make expands a whole recipe before running its first line, so the original
$(shell)glob forMLX_ROOTresolved before the venv existed. It now resolves in the shell inside the stamp recipe.Cost
The darwin
vllm-cppimage grows by about 124 MB.Notes
-metal-darwin-arm64-vllm-cpp), so no CI matrix change is needed.MLX_VERSIONinto the existing dep-bump automation, as done forvllm-metal.